weakself

Why app architecture matters (even for simple apps)

This article is a follow up of the Multiplayer game with Group Activities article. If you missed it, you might want to take a look at it. In this article we created a simple multiplayer Pong game using Group Activities to manage the in-game communications.

If you are interested, you can also have a look at the final code implementation

Why is software architecture important?

There are a lot of buzzwords that come to our mind when talking about this: scalability, testability, open-close principle, interface segregation, dependency inversion,..

The truth is that there are a lot of principles and patterns, but you should not apply them blindly. Every project is different and what is great for a complex project where a big team is working on, might not be the best fit for a small demo project where only a single contributor is working on.

Why is it important in our simple Pong game?

At first glance you might think that for a small demo app with a simple game like Pong this really doesn't matter. But looking into it a bit more in depth, you will notice that the remote multiplayer aspect makes everything very hard to test and the overall development process very slow.

If we don't do something about it, while developing you would need to do every time:

  1. Install the app on physical device 1
  2. Install the app on physical device 2
  3. Establish a Facetime call between device 1 and device 2
  4. Join the group session on device 1
  5. Join the group session on device 2
  6. Notice that something is not right
  7. Fix it
  8. Go to step 1 and start all over again :(

This is obviously not feasible. You need to test the game play, the message synchronization, different device screen sizes,...impossible if we don't improve the development process.

How can we improve that?

Let's focus first on the game logic and the game play. To test just the game logic we can avoid the GroupActivity, and treat it as it would be just a local game.

Game play
  • GameLogic holds the actual game logic, handling player objects, ball, collisions,..
  • GameController receives the gesture drag input and publishes the player and opponent position updates
class GameController {
    var movePlayerPublisher: AnyPublisher<CGFloat, Never> {
        movePlayerSubject.eraseToAnyPublisher()
    }

    private let movePlayerSubject = PassthroughSubject<CGFloat, Never>()

    var moveOpponentPublisher: AnyPublisher<CGFloat, Never> {
        moveOpponentSubject.eraseToAnyPublisher()
    }

    private let moveOpponentSubject = PassthroughSubject<CGFloat, Never>()

    func onDrag(dragLocation: CGPoint, screenSize: CGSize) {
        let y = dragLocation.y / screenSize.height
        if y < 0.5 {
            moveOpponentSubject.send(dragLocation.x / screenSize.width)
        }
        else {
            movePlayerSubject.send(dragLocation.x / screenSize.width)
        }
    }
}

With that in place we can test and iterate on the actual game play.

Now let's bring back in the GroupActivity.

First off all let's add an interface to be able to swap easily between one and the other implementation:

Game play

With this in place we can create the PongActivityController and conform it to the game related protocols.

  • PongActivityController handles the group session, the players and it's communication
  • It intercepts the game input and output and forwards the messages
  • Conforms to the GameController protocol, where a drag moves the local player and the incoming GroupActivity messages move the opponent

With this, we can add the GroupActivity functionality in a clean way without the need to modify any other components of the system.

Game play

But what happens now? We have not improved the testing and slow development feedback loop we have mentioned before. To test the group sessions, participants and messaging we still need to run our app on two different devices and create a Facetime call.

Instead of that, ideally, our goal is to:

  • Be able to run everything in a SwiftUI Preview, visualizing both devices at once, without the need of a physical device or a Facetime call.

How do we achieve that?

The key is to abstract away the GroupActivitiy and be able to mock it.

Pong Group Activities abstraction
struct PongParticipant: Hashable {
    let id: UUID
}

protocol PongGroupActivity {
    var sessionsPublisher: AnyPublisher<PongGroupSession, Never> { get }
}

protocol PongGroupSession {
    var localPongParticipant: PongParticipant { get }
    var statePublisher: AnyPublisher<GroupSession<PongActivity>.State, Never> { get }
    var state: GroupSession<PongActivity>.State { get }
    func messenger(deliveryMode: GroupSessionMessenger.DeliveryMode) -> PongGroupSessionMessenger
    var activeParticipantsPublisher: AnyPublisher<Set<PongParticipant>, Never> { get }
    func join()
    func leave()
}

protocol PongGroupSessionMessenger {
    var deliveryMode: GroupSessionMessenger.DeliveryMode { get }
    func send<Message: Codable>(_ message: Message, completion: @escaping (Error?) -> Void)
    func messages<Message: Codable>(of type: Message.Type) -> AnyPublisher<Message, Never>
}

With this in place we replace GroupSession<PongActivity> with PongGroupSession, Participant with PongParticipant and GroupSessionMessenger with PongGroupSessionMessenger.

Let's take the example of the GroupSession state to see how this improves the development process.

Our live implementation still returns the actual GroupSession state and needs a Facetime call to work.

extension GroupSession<PongActivity>: PongGroupSession {
    var statePublisher: AnyPublisher<GroupSession<PongActivity>.State, Never> {
        $state.eraseToAnyPublisher()
    }
}

While developing and testing we inject instead a mock group session implementation. With this we can simulate the group session state without the need of establishing any Facetime call.

struct MockPongGroupSession: PongGroupSession {
	var statePublisher: AnyPublisher<GroupSession<PongActivity>.State, Never> {
        Just(.joined).eraseToAnyPublisher()
    }

    var state: GroupSession<PongActivity>.State { .joined }
}

In this mock we instantly are in a joined state, but we could easily simulate other scenarios, like delaying the joined state or leaving / failing the group session returning an invalidated(reason: Error) state.

With the particpants of the group session we do the same. We mock the var activeParticipantsPublisher: AnyPublisher<Set<PongParticipant> publisher and update the participants Set at will.

Note: The particpants Set sequence won't be the same if the player is the first one joining the group or if he joins and a player is already waiting in the session

What about the messages?

For the messages that a player receives we could follow the same approach mocking the different messages, but to have a more realistic input we are going to connect two mock PongGroupSessionMessenger.

Group Activities mock messengers

The idea is that we create two mock PongParticipant and we connect the corresponding messengers. The output of one messenger is the input of the other messenger.

class MockPongGroupSessionMessenger: PongGroupSessionMessenger {
    var output: MockPongGroupSessionMessenger? = nil
    var subjects: [String: any Subject] = [:]

    var deliveryMode: GroupSessionMessenger.DeliveryMode {
        .reliable
    }

    func send<Message>(_ message: Message, completion: @escaping (Error?) -> Void) where Message : Decodable, Message : Encodable {
        guard let subject = output?.subjects[String(describing: Message.self)] as? PassthroughSubject<Message, Never> else {
            return
        }
        subject.send(message)
    }

    func messages<Message>(of type: Message.Type) -> AnyPublisher<Message, Never> where Message : Decodable, Message : Encodable {
        let subject = PassthroughSubject<Message, Never>()
        subjects[String(describing: Message.self)] = subject
        return subject.eraseToAnyPublisher()
    }
}

And when we create our mock implementation we just assign the messengers output

localConfig.messenger.output = remoteConfig.messenger
remoteConfig.messenger.output = localConfig.messenger

Wrapping up

Putting it all together, for testing we create two GameViews (mock player 1 and mock player 2) with different sizes and inject our GroupActivity mocks.

struct GameViewGroupActivityTest: View {
    let local: GameViewModel
    let remote: GameViewModel

    var body: some View {
        VStack(spacing: 0) {
            GameView(game: local)
            GameView(game: remote)
                .padding(.horizontal, 250)
        }
        .background(Color.gray)
    }
}

Now we can use a simple SwiftUI Preview, iterate very fast and test all kind of scenarios with ease.

Moreover, besides of the obvious development and testing benefit, adding this abstractions and doing this simple separation of concerns allows us also to:

  • Add different game modes with ease (two local players, single local, two remote,..)
  • Swap out concrete implementations (Websocket instead of GroupActivity, SpriteKit instead of SwiftUI,..)
Note: App architecture is great but you should also not try to "over architect". You should try to tailor it to the actual project, because every project has its purpose and its needs. For example it would not benefit much our simple Pong game if we start creating multiple packages and separate everything in different modules, at least not in the current state of the app.

If you want to find out more details you can also have a look at the code implementation.

Tagged with: